Skip to content

Execute multiple contract calls atomically#1702

Open
yrong wants to merge 11 commits into
mainfrom
ron/multi-contract-calls
Open

Execute multiple contract calls atomically#1702
yrong wants to merge 11 commits into
mainfrom
ron/multi-contract-calls

Conversation

@yrong

@yrong yrong commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support to execute multiple contract calls atomically. Sub-calls are run in order and the entire command reverts on the first failure.

Behavior

  • CallContracts: Runs sub-calls sequentially. If any sub-call fails, the execution reverts, marking the command as failed.
  • Payload format: CallContractsParams contains a list of sub-calls (calls).

Requires: paritytech/polkadot-sdk#12383

@codecov

codecov Bot commented Feb 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.61%. Comparing base (d386674) to head (089468b).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1702      +/-   ##
==========================================
+ Coverage   76.90%   78.61%   +1.70%     
==========================================
  Files          24       24              
  Lines         983      996      +13     
  Branches      186      188       +2     
==========================================
+ Hits          756      783      +27     
+ Misses        203      190      -13     
+ Partials       24       23       -1     
Flag Coverage Δ
solidity 78.61% <100.00%> (+1.70%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@yrong yrong changed the title Add multiple contract calls Call multiple contracts with optional Sweep-on-Failure Feb 9, 2026
@yrong yrong changed the title Call multiple contracts with optional Sweep-on-Failure Execute multiple contract calls atomically with optional sweep-on-failure Feb 15, 2026
Comment thread contracts/src/Gateway.sol Outdated
external
onlySelf
{
CallContractsParams memory params = abi.decode(payload, (CallContractsParams));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Params are decoded here for a second time, can probably send CallContractsParams to trySweepOnFailure.

Comment thread contracts/test/GatewayV2.t.sol Outdated
CallContractParams[] memory params = new CallContractParams[](1);
params[0] =
CallContractParams({target: address(0xdead), data: "", value: uint256(0)});
bytes memory payload = abi.encode(params);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
bytes memory payload = abi.encode(params);
CallContractsParams memory p = CallContractsParams({
calls: params,
sweepRecipient: address(0),
tokensToSweep: new address[](0)
});
bytes memory payload = abi.encode(p);

Since it should be a CallContractsParams struct, not CallContractParams[] array.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed this by wrapping the parameters in the CallContractsParams struct.

Comment thread contracts/src/AgentExecutor.sol Outdated
}

// Sweep remaining assets when specified
function sweep(address recipient, address[] calldata tokens) external {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can unrelated assets be swept unintentionally here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically, the sweep token list is constructed by the SDK and kept consistent with the transfer token list.

By the way, the sweep flow is optional and can be skipped by providing an empty address.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I like this PR in theory. But I would like to build this at a higher level. So here we are mimicking the Asset Claimer behaviour by using (maybe abusing) Transact and the fact that we can implement anything via arbitrary contract call, rather than sticking close to the XCM functionality and build it into our protocol directly.

For instance we should probably stick closer to the XCM spec, and by default always sweep assets on any failure. By default we should sweep assets to the locations Agent, however if SetHints{Claimer} is set then then we use that account. The asset list should be filled in by the on-chain code from the XCM ground truth instead of expecting the sdk to build it correctly offchain. Also if a user has an agent, JIT create the Agent if it does not exist. Jit creating an agent and paying more gas, but having funds recoverable is probably better than failing and having funds trapped in limbo.

If a user didnt specify any claimer and the funds got sent to the agent, we could use ClaimAsset instruction to get them back for instance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback, Alistair.

While sticking closer to the XCM spec for claims/sweeps is a good architectural direction for protocol-level asset recovery, CallContracts is actually a prerequisite for a safer, revamped contract interaction pattern we are moving towards.

Specifically, the target pattern is the standard DeFi "Approve-then-Call" flow executed atomically by the agent in a single transaction:

  1. Approve: The user agent calls the ERC20 token contract to approve the adapter (e.g. Across SpokePool/L1 Adaptor) to transfer assets on its behalf.
  2. Call: The user agent calls the adapter to execute the action (which pulls the approved assets).

Executing these two steps atomically via CallContracts resolves a major security risk: we no longer need to pre-fund shared adapters. Prefunding adapters across transaction boundaries has been the subject of multiple security reports due to the risk of front-running/drainage.

Having arbitrary atomic multi-calls gives us the flexibility to use safe patterns like this without relying on prefunding or custom protocol-level sweeping shims.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This latest implementation is much simpler and I am for it.

yrong added 2 commits June 15, 2026 23:45
…ls execution

Simplifies the CallContracts command by removing the optional sweep-on-failure
feature. Multiple contract calls will now execute atomically, reverting on the
first failure and marking the command as failed, without attempt to recover and
sweep remaining funds within the gateway loop. This eliminates significant
complexity across the gateway, handlers, executor, types, events, and tests.
@yrong yrong changed the title Execute multiple contract calls atomically with optional sweep-on-failure Execute multiple contract calls atomically Jun 15, 2026
Wrap the CallContractParams array in the CallContractsParams struct before encoding
to avoid decoding errors inside the gateway dispatcher during execution.
Comment thread contracts/src/AgentExecutor.sol Outdated
for (uint256 i; i < len; ++i) {
bool success = Call.safeCall(params[i].target, params[i].data, params[i].value);
if (!success) {
revert();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The revert does not forward inner contracts revert data. May help debugging and we should do the same for the singular callContract version.

@yrong yrong Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented and bubbled up the revert data for both callContract and callContracts using assembly returndatacopy/revert. Added unit tests to GatewayV2.t.sol for verification.
f905d40

Comment thread contracts/test/GatewayV2.t.sol Outdated
);
}

function testAgentCallContractsRevertedOnSecondFailure() public {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of emitting an event with sayHello and testing for its non-existance can we test an actual balance update? Like a test which covers Transfer/Approve/Send like in the case with L2s.

@yrong yrong Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added two integration tests to GatewayV2.t.sol to cover the Approve/Transfer/Send pattern using WETH and a mock adapter (MockAdapter):

  1. testAgentCallContractsTransferApproveSendSuccess: Verifies the success path, asserting that the agent's WETH balance is debited and the recipient's WETH balance is credited.
  2. testAgentCallContractsTransferApproveSendFailureRevertsAll: Verifies the failure path, asserting that when a subsequent contract call fails (e.g. attempting to send more than available), the entire state is reverted (meaning both agent and recipient WETH balances remain unchanged, and the allowance granted during the first step is reverted back to 0).

3e7238d

yrong added 3 commits June 16, 2026 13:46
Move the duplicated returndata-bubbling assembly out of
AgentExecutor.callContract/callContracts into a shared
Call.bubbleRevert() helper, annotated memory-safe. No behavioral change. Also tidy whitespace in GatewayV2 tests.
Comment thread contracts/src/v2/Types.sol Outdated
// Call an arbitrary solidity contract
uint8 constant CallContract = 5;
// Call multiple arbitrary solidity contracts
uint8 constant CallContracts = 6;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CallContract and CallContracts are too similar and easily confused. Could lead to issues.

How about calling itMultiCall?

@yrong yrong Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — renamed the whole plural family to `MultiCall`

  • CallContracts command kind → MultiCall
  • CallContractsParamsMultiCallParams
  • handler/executor callContractsmultiCall

The singular CallContract / CallContractParams types are unchanged and still represent per-call execution. The on-the-wire encoding remains unchanged (uint8 = 6).

This was done in commit 089468b, and is also synced upstream in: paritytech/polkadot-sdk@533668e

yrong added a commit to yrong/polkadot-sdk that referenced this pull request Jun 19, 2026
Align the Rust outbound-queue primitives with the Gateway contract rename
in Snowfork/snowbridge#1702, where the `CallContracts` command became
`MultiCall`.

- `Command::CallContracts` -> `Command::MultiCall` (kind 6 unchanged)
- ABI struct `CallContractsParams` -> `MultiCallParams`
- Update converter mapping and tests accordingly

The single-call `CallContract` command (kind 5) is left untouched.
yrong added a commit to yrong/polkadot-sdk that referenced this pull request Jun 19, 2026
Align the outbound-queue primitives with the Gateway contract changes in
Snowfork/snowbridge#1702 and make the XCM Transact payload self-describing.

- Rename `Command::CallContracts` -> `Command::MultiCall` (kind 6
  unchanged) and the ABI struct `CallContractsParams` -> `MultiCallParams`.
- Rename `ContractCall` payload variants `V1`/`V2` -> `Single`/`Multi`,
  which read as a call mode rather than a protocol version.
- Update the converter mapping, tests, and prdoc accordingly.

The single-call `Command::CallContract` (kind 5) is left untouched. The
encoding is unchanged; these are source-level renames.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants